/*=============================================================================
    jibits/dialog.js
    Copyright (C) 2008-2009, Donald W. Griffin
    All rights reserved.
    [MIT license :: see license.txt for details]
=============================================================================*/
$module("jibits.dialog");

$requires("jibits.popup");

$namespace(jibits, {

/**
This class maps a set of hotkey definitions to opaque values. These values can
be anything useful to the user. For shared hotkey mapping, a given object may be
linked to a parent object.

For example:

    var hk1 = new jibits.HotKeys(
                {
                    enter : function (ev) { },
                    $32 : function (ev) { }
                });

    var hk2 = new jibits.HotKeys(hk1,
                {
                    ctrl_alt_$34 : function (ev) { }
                });

    var fn = hk2.map(ev); // passing a key event
    if (fn)
        fn();

All keynames consist of case-insensative components separated by underbars. The
base keycode can be a well-known name or a "$##" decimal numeric code. Following
are the well-known key names:

    * bksp
    * tab
    * enter
    * esc
    * home
    * end
    * ins
    * del
    * pgup
    * pgdn
    * space
    * left (arrow)
    * up (arrow)
    * right (arrow)
    * down (arrow)
    * F1 - F12
    * A - Z
    * 0 - 9
    * colon (;:)
    * equal (=+)
    * comma (,&lt;)
    * dot (.>)
    * slash (/?)
    * bkquote (`~)
    * openbr ([{)
    * closebr (]})
    * bkslash (\|)
    * quote ('")
    * np0 - np9 (num pad 0 - 9)
    * npstar
    * npslash
    * npminus
    * npplus
    * npdot

The keycode is always the last component of a keyname. The other components are
modifiers. The following are the recognized modifiers:

    * ctrl
    * alt
    * shift

Some other examples: CTRL_ENTER (or ctrl_enter or ctrl_$13), ctrl_F10, alt_F1.

Key codes from: http://www.webonweboff.com/tips/js/event_key_codes.aspx.
*/
HotKeys : $class(function ()
{
    var KEYS = { bksp:8, tab:9, enter:13, esc:27, space:32, pgup:33, pgdn:34, end:35,
                 home:36, ins:45, del:46, left:37, up:38, right:39, down:40, colon:59,
                 equal:61, comma:188, dot:190, slash:191, bkquote:192, openbr:219,
                 closebr:221, bkslash:220, quote:222, npstar:106, npslash:111,
                 npminus:109, npplus:107, npdot:110 };

    for (var i = 1; i <= 12; ++i)
        KEYS["f"+i] = 111 + i; // F1 == 112
    for (i = 0; i < 10; ++i)
    {
        KEYS[""+i] = 48 + i; // 0 == 96
        KEYS["np"+i] = 96 + i; // np0 == 96
    }
    for (i = 0; i < 26; ++i)
        KEYS["abcdefghijklmnopqrstuvwxyz".charAt(i)] = i + 65; // A == 65

    function modToken (c, a, s)
    {
        return (c ? "C" : "_") + (a ? "A" : "_") + (s ? "S" : "_");
    }

    // Canonicalizes the hotkey (e.g., the text "CTRL_ALT_HOME", "ALT_CTRL_HOME",
    // "CTRL_ALT_$36" and "ALT_CTRL_$36" (and any random capitalization of these)
    // are equivalent hotkeys. The canonical form is "CAS##", where the "C" is for
    // CTRL, "A" is for ALT and "S" is for SHIFT. If any of these is not present,
    // an "_" is used. The "##" part is the decimal key code. So, "CTRL_ALT_HOME"
    // is "CA_36".
    function canonicalize (k)
    {
        var ks = k.split("_"), n = ks.length - 1, c = ks[n];
        var key, m = { ctrl:false, alt:false, shift:false };

        for (var i = 0; i < n; ++i)
        {
            key = ks[i].toLowerCase();
            $assert(key in m);
            m[key] = true;
        }

        var mod = modToken(m.ctrl, m.alt, m.shift); // e.g. "CAS"
        key = mod + (c.startsWith("$")  // if ("$##")
                            ? parseInt(c.substring(1))
                            : KEYS[c.toLowerCase()]);

        return key;
    }

    return (
    {
        ctor : function (parent, keys)
        {
            if (arguments.length == 1)
            {
                keys = parent;
                parent = null;
            }

            this.parent = parent;
            this.keys = {};

            for (var k in keys)
                this.keys[canonicalize(k)] = keys[k];
        },

        map : function (ev)
        {
            var key = (typeof(ev) === "string")
                        ? canonicalize(ev)
                        : modToken(ev.ctrlKey, ev.altKey, ev.shiftKey) + ev.keyCode;

            if (key in this.keys)
                return this.keys[key];

            if (this.parent)
                return this.parent.map(ev);

            return null;
        }
    });
}()),

/**
This class manages the pane/div that provides modal behavior by catching clicks.
The pane needs to be immediately below the element that is to remain active as
well as fill the browser window. This is handled by
*/
ModalPane : $class(jibits.Widget, function ()
{
    function clicked (ev)
    {
        ev.consume();
        var active = $widget(this.getActive());
        if (active && "onClickOut" in active)
            active.onClickOut();
    }

    function disableTags (tag)
    {
        if (!this.disables)
            this.disables = [];

        var els = document.getElementsByTagName(tag);
        $foreach(els, function (el)
        {
            if (el.disabled)
                return;
            this.disables.push(el);
            el.disabled = true;

        }.bind(this));
    }

    function enableTags ()
    {
        $foreach(!this.disables, function (el)
        {
            el.disabled = false;
        });

        this.disables = null;
    }

    function getDim (dim)
    {
        var t, v;
        if ((t = document.documentElement) && !(v = t[dim]))
            if ((t = document.body) && !(v = t[dim]))
                if ((t = t.parentNode))
                    v = t[dim];
        return v || 0;
    }

    function ignore (ev)
    {
        ev.consume();
    }

    function onResize ()
    {
        var w = getDim("clientWidth"), h = getDim("clientHeight");

        this.setStyle({ width: w+"px", height: h+"px" });
    }

    return (
    {
        ctor : function (id, modalStyle)
        {
            $super(arguments).call(this, id || "jibitsGlobalModalPane");

            this.activeSet = {};
            this.activeNum = 0;
            this.hasFixed = !zjs.browser.ie || zjs.browser.version >= 7;
            this.modalStyle = modalStyle || "jibitsModalPane";

            var style = {
                            display : "none",
                            position : (this.hasFixed ? "fixed" : "absolute"),
                            left : "0px", top : "0px"
                        };

            if (this.hasFixed)
                style.width = style.height = "100%";

            var el = jibits.newChildDiv(document.body, this.id,
                        {
                            className : this.modalStyle,
                            style : style
                        });

            this.connect();
            this.listenSelf(ignore, "mousedown", "mouseup");
            this.listenSelf(clicked.bind(this), "click");
        },

        destroy : function ()
        {
            jibits.removeElem(this.id);
            $super(arguments).apply(this, arguments);
        },

        activate : function (refid, nowActive)
        {
            var poll = true, wasActive = (refid in this.activeSet);

            // If the ref element wants us active (and it didn't before), we add
            // it to our activeSet...
            if (nowActive)
            {
                if (!wasActive)
                {
                    this.activeSet[refid] = true;
                    ++this.activeNum;
                }
            }
            else if (wasActive)
            {
                // else, the ref element previously wanted us to be active and
                // now does not, so remove it from our activeSet...
                delete this.activeSet[refid];
                --this.activeNum;
            }
            else
                poll = false;

            if (poll)
                this.pollSoon();

            return this.activeNum > 0;
        },

        getActive : function ()
        {
            for (var top = document.body.lastChild; top; top = top.previousSibling)
                if (this.activeSet[top.id])
                    return top;
            return null;
        },

        sync : function ()
        {
            $super(arguments).call(this);

            if (this.activeNum > 0)
            {
                var top = this.getActive();
                if (top)
                {
                    var el = this.elem(), z = jibits.getStyle(top, "z-index");
                    if (z)
                        el.style.zIndex = z;

                    var tw = $widget(top);
                    el.className = (tw ? tw.modalStyle : null) || this.modalStyle;

                    this.moveBelow(top); // put the pane below the top active
                }

                if (!this.isVisible())
                {
                    disableTags.call(this, "object");
                    if (zjs.browser.ie && zjs.browser.version < 7)
                        disableTags.call(this, "select");

                    if (!this.hasFixed && !this.listener)
                        this.listener = this.listen(window, "resize",
                                                    onResize.bind(this));

                    this.show();
                }
            }
            else if (this.isVisible())
            {
                enableTags.call(this);

                if (this.listener)
                {
                    this.listener.destroy();
                    this.listener = null;
                }

                this.hide();
            }
        }
    });
}()),

/**
A dialog wraps a DOM element and provides an "execute" metaphor. The wrapped
element can either be detached from its initial location in the DOM or it can be
cloned from a node that serves as its template. In all cases, the dialog will be
absolutely positioned as a child of document.body.

The (optional) config parameter to the constructor specifies the following
properties that control the dialog's behavior:

    * decorator: An object to decorate the element. This object must have a method
            with the following name and signature:

                    Element decorate (Element el);

            The decorate method accepts the element to decorate and returns the
            decoration element (typically the new parent of the given element).

    * defChild: The ID (template-relative) of the default child element. See
            setDefChild method.

    * modal : True if this dialog should be modal.
    * modalStyle : The style (CSS className) of the modal layer.

    * hotkeys : The hotkey definition. The value of a hotkey can be either the
            handler method or the command name.

    * commands : The set of commands and their handlers. Each property of this
            object consists of a command name and its respective handler. The
            command name can be anything, but if it happens to match that of a
            child element, an onclick event handler is established to call the
            handler.

    * template: The Template object to use (see the jibits.Template class).
    * ctx: The template's context data (see jibits.Template). Only applies when
            using the template option.
*/
Dialog : $class(jibits.Popup, function ()
{
    var _pane; // effectively private static

    function doEnabler (cmd)
    {
        var en = new jibits.Enabler(cmd, this.getChild(cmd));
        var def = this.commands[cmd];

        if (def && def.enabler)
            def.enabler(en);

        return en.enabled;
    }

    function doCmd (cmd, ev)
    {
        var def = this.commands[cmd];
        if (def && def.handler)
            if (doEnabler.call(this, cmd))
            {
                def.handler();
                ev.consume();
            }
    }

    function doChildKey (child, ev)
    {
        var def = this.commands[child], cmd = def ? def.hotkeys.map(ev) : null;

        if (cmd instanceof Function)
        {
            cmd();
            ev.consume();
        }
        else if (cmd)
            doCmd.call(this, cmd, ev);
    }

    function doHotKey (ev)
    {
        var cmd = this.hotkeys.map(ev);

        if (cmd instanceof Function)
        {
            cmd();
            ev.consume();
        }
        else if (cmd)
            doCmd.call(this, cmd, ev);
    }

    function init ()
    {
        if (this._commands)
        {
            var docmd = doCmd.bind(this), dokey = doChildKey.bind(this);
            for (var c in this._commands)
            {
                var def = { }, cv = this._commands[c], child = this.getChild(c);

                if (cv instanceof Function)
                    def.handler = cv;
                else
                {
                    def.handler = cv.handler;
                    if (cv.hotkeys)
                    {
                        def.hotkeys = new jibits.HotKeys(this.hotkeys, cv.hotkeys);
                        if (child)
                            this.listenChild(c, dokey.head(c), "keydown");
                    }
                    if (cv.enabler)
                        def.enabler = cv.enabler;
                }

                this.commands[c] = def;

                if (child && def.handler)
                    this.listenChild(c, docmd.head(c), "click");
            }

            delete this._commands;
        }

        if (this.hotkeys)
            this.listenSelf(doHotKey.bind(this), "keydown");

        this.onInit();
    }

    function preShow ()
    {
        if (!this.inited)
        {
            this.inited = true;
            init.call(this);
        }

        this.onPreShow();
    }

    function postExec ()
    {
        if (this.activeElementWas)
        {
            try { this.activeElementWas.focus(); } catch (e) { }
            delete this.activeElementWas;
        }

        this.onPostExec(this.execResult);
    }

    function postShow ()
    {
        if (this.defChild)
        {
            var c = $elem(this.baseid + this.defChild);
            if (c)
                try { c.focus(); } catch (e) { }
        }

        this.onPostShow();
    }

    return (
    {
        /**
        Initializes a dialog given various attributes.
        */
        ctor : function (id, config)
        {
            $super(arguments).apply(this, arguments);

            this.commands = {};

            var el, generated = (config && config.template);

            if (!generated)
            {
                this.baseid = "";
                el = $elem(id);
                el.parentNode.removeChild(el); // only temporary...
            }
            else
            {
                this.baseid = id + "_";
                el = config.template.generate(id, config.ctx);
            }

            if (config)
            {
                if ("decorator" in config)
                    el = config.decorator.decorate(el);

                this._commands = config.commands; // processed by init()
                this.defChild = config.defChild;
                this.modal = config.modal;
                this.modalStyle = config.modalStyle;

                if (config.hotkeys)
                    this.hotkeys = new jibits.HotKeys(config.hotkeys);
            }

            jibits.setStyle(el, { display:"none", position:"absolute" });
            document.body.appendChild(el);

            if (generated)
                jibits.parseWidgetSubTree(el);

            this.connect();
        },

        destroy : function ()
        {
            $super(arguments).apply(this, arguments);

            jibits.removeElem(this.id);

            this.syncModal(false);
        },

        //----------------------------------
        // Execute life-cycle

        dismiss : function (result)
        {
            $assert(this.isVisible());

            $super(arguments).apply(this, arguments);

            this.execResult = result;
            this.hide();
        },

        execute : function ()
        {
            $assert(!this.isVisible());

            $super(arguments).apply(this, arguments);

            this.activeElementWas = document.activeElement || document.focusNode;
            this.show();
        },

        onInit : function () { $super(arguments).apply(this, arguments); },
        onPreShow : function () { $super(arguments).apply(this, arguments); },
        onPostShow : function () { $super(arguments).apply(this, arguments); },
        onPostExec : function (result) { $super(arguments).apply(this, arguments); },

        //----------------------------------
        // Child management:

        getChild : function (relid)
        {
            return $elem(this.getChildId(relid));
        },

        getChildId : function (relid)
        {
            return this.baseid + relid;
        },

        listenChild : function (cid, handler, events)
        {
            var id = this.getChildId(cid);
            return this.listen(id, handler, events);
        },

        setDefChild : function (relid)
        {
            $super(arguments).apply(this, arguments);
            this.defChild = relid;
        },

        //----------------------------------
        // misc.

        setVisible : function (vis)
        {
            if (vis)
                preShow.call(this);

            this.syncModal(vis);

            $super(arguments).apply(this, arguments); // make it so!

            if (vis)
                postShow.call(this);
            else
                postExec.call(this);
        },

        sync : function ()
        {
            $super(arguments).apply(this, arguments);

            this.syncModal(this.isVisible());
        },

        syncModal : function (vis)
        {
            var active = this.modal && vis;

            if (!_pane)
            {
                if (!active)
                    return;

                _pane = new jibits.ModalPane();
            }

            if (_pane.activate(this.id, active)) // if (still has users)
                return;

            _pane.destroy();
            _pane = null;
        }
    });
}()),

Enabler : $class(
{
    ctor : function (id, el)
    {
        this.id = id;
        this.el = el;
        this.widget = $widget(el);

        if (this.widget)
        {
            this.enabled = this.widget.isEnabled();
            this.checked = this.widget.isChecked();
        }
        else if (el)
        {
            this.enabled = !el.disabled;
            this.checked = el.checked;
        }
    },

    elem : function ()
    {
        return this.el;
    },

    enable : function (b)
    {
        b = b || (arguments.length == 0);
        this.enabled = b;

        if (this.widget)
            this.widget.enable(b);
        else if (this.el)
            this.disabled = !b;

        return this;
    },

    setCheck : function (b)
    {
        b = b || (arguments.length == 0);
        this.checked = b;

        if (this.widget)
            this.widget.setCheck(b);
        else if (this.el)
            this.el.checked = b;

        return this;
    },

    setText : function (s)
    {
        this.text = s;

        if (this.widget)
            this.widget.setText(s);
        else if (this.el)
            jibits.setText(this.el, s);

        return this;
    }
})

}); // namespace jibits
